Skip to content

cxp-846 return XML as a generic map: map targets and non-map roots - #1061

Open
agustin-conductor wants to merge 2 commits into
mainfrom
bugfix/xml-parser
Open

cxp-846 return XML as a generic map: map targets and non-map roots#1061
agustin-conductor wants to merge 2 commits into
mainfrom
bugfix/xml-parser

Conversation

@agustin-conductor

@agustin-conductor agustin-conductor commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Two fixes that make uhttp able to hand an arbitrary XML document back as a generic map.

1. WithAlwaysXMLResponse rejected map targets. It hands its target straight to encoding/xml, which cannot unmarshal into a map, so a *map[string]any target failed for every response with a body:

failed to unmarshal xml response: unknown type map[string]interface {}. status code: 200

Route that one target type through the xmlMap decoder WithGenericResponse already uses, sharing the code as unmarshalXMLToMap.

2. A non-map XML root hard-failed. unmarshalXMLToMap required the root element's content to be a map and returned Internal: unsupported XML structure otherwise — which a root-level list hits, a common API shape. Key those documents by the root element name instead. Added in response to review feedback; unmarshalXMLToMap can no longer fail on structure at all.

Scope note. An earlier revision also reshaped the decoder so repeated siblings grouped under their shared key. That commit is dropped — see Deferred. Nothing here changes an existing shape: xml.go gains a root field, but no decoding logic.

Why

Callers wanting an arbitrary XML document as a map have no working option today. Concretely, baton-http maps parse_as: xml onto WithAlwaysXMLResponse(&map[string]any{}), so that config key has never functioned since it was added in 65f49692 — it hard-fails on every response with a body.

And fix 1 alone would not have been enough for the shape that matters most. <Users><User/><User/></Users> decodes to a []map[string]any, so it still died in unmarshalXMLToMap — arguably the main case parse_as: xml is wanted for.

Compatibility

Scoped by target type. The new branch in WithAlwaysXMLResponse fires only for *map[string]any; every other target falls through to the unchanged xml.Unmarshal call. All existing call sites in the connector fleet pass typed structs or nil. WithXMLResponse — which panorama, litmos, and sage-intacct use — is not modified, and a test pins that it still rejects map targets.

Every divergence is error → something else, never success → something else:

input, map target before after
XML body error unknown type map[string]interface {} decoded map
204 / empty 2xx body error nil, map left empty
typed-nil (*map[string]any)(nil) error nil pointer passed to Unmarshal InvalidArgument: response is nil
root's children repeat (<Users><User/><User/></Users>) error unsupported XML structure: []map[string]interface {} {"Users": [{"User":…},{"User":…}]}
root holds only text (<Code>OK</Code>) error unsupported XML structure: string {"Code": "OK"}

The last two rows also apply to WithGenericResponse, since both paths share unmarshalXMLToMap. Both were hard errors there too, so the same argument covers it — but note that this PR does change generic-path behavior for those two document shapes, not only the map target.

WithAlwaysXMLResponse and WithGenericResponse now produce byte-identical output for the same document, asserted by test. That matters downstream: a baton-http config gets the same tree whether or not it sets parse_as: xml.

The WithGenericResponse refactor is a pure extraction: its XML branch previously routed through WithXMLResponse(&xm), whose content-type and nil checks are both dead inside that branch (already guarded by IsXMLContentType, and &xm is never nil). Same decoder, same error wrapping, one code path.

The typed-nil guard addresses the earlier review finding: the map branch would have turned encoding/xml's clean "nil pointer passed to Unmarshal" into a nil-pointer panic. It lives inside unmarshalXMLToMap rather than at each call site, so assigning through the pointer cannot panic. A typed nil survives an any == nil check because the interface still carries a type.

Known limitation: the arity seam

Keying by the root name is reactive, so it inherits the decoder's arity asymmetry:

document decoded path
<users><user/><user/></users> {"users": [{"user":…},{"user":…}]} users
<users><user/></users> {"user": {…}} user

One child means nothing repeats, so the content is a map, so the root name is discarded as always. One config cannot serve both arities for a root-level list. This is pinned by a test rather than left to be rediscovered.

It is still a clear improvement: previously the ≥2 case — the one essentially every real tenant hits — failed outright, and the error it produced was an opaque Internal from inside the SDK rather than something a config author could act on.

Grouping repeated children under their shared name is what closes the seam, and it would make the slice case here unreachable. Nested lists already have no seam, thanks to ConductorOne/baton-http#144.

Testing

go build ./..., go test ./pkg/uhttp/..., and golangci-lint run ./pkg/uhttp/... (0 issues) all pass.

Cases on WithAlwaysXMLResponse: map target decoding despite a non-XML content type, the typed-struct path unchanged, a root-level list keyed by the root name, a single-child root still stripping it (the seam), a text-only root keyed by the root name, 204 and empty-200 leaving the map untouched, a typed-nil target erroring rather than panicking, and WithXMLResponse still rejecting map targets.

Verified end-to-end against baton-http#144 through a Go workspace — raw XML → WithGenericResponseExtractItems:

root-level list, 2 users   items_path=users   2 items    (was: SDK hard error)
root-level list, 1 user    items_path=user    1 item
nested list, 2 users       items_path=users   2 items
nested list, 1 user        items_path=users   1 item     <- same path both arities
text-only root             items_path=code    correctly "not an array, got string"

Deferred: the decoder shape change

The dropped commit made a container with 2+ same-named children decode to {"USER_LIST": {"USER": [...]}} instead of {"USER_LIST": [{"USER":…},{"USER":…}]}, so that jsonpath could walk it.

It is not needed for the consumer problem it targeted — baton-http's items_path failing on XML list responses — which is fixed entirely by ConductorOne/baton-http#144, at the extraction sites, with no SDK release.

And it carries a risk this PR does not. []map[string]any is only untraversable for jsonpath; CEL and Go templates walk it fine. In baton-http, responses on the provisioning, action, and pre-request paths never reach items extraction and are read solely by CEL — so cel:size(response.body.USER_LIST) returns N today and would return 1 after the reshape: a silent wrong answer in a config that works.

Worth noting it has gained a second motivation, though — it is also what would close the arity seam above and retire the slice branch entirely. So it is deferred for its own audit, not dismissed.

Part of CXP-846

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Aug 5, 2026

Copy link
Copy Markdown

CXP-846

Comment thread pkg/uhttp/wrapper.go
if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(resp.Body) == 0 {
return nil
}
return unmarshalXMLToMap(genericResponse, resp)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: A typed-nil (*map[string]any)(nil) passes the response == nil check above (interface holds a type), then this assertion succeeds with a nil genericResponse, and unmarshalXMLToMap does *response = vMap → nil-pointer panic on a non-empty body. WithGenericResponse guards this with an explicit nil check; consider mirroring it here. Low confidence — an unusual call pattern, but the map branch is new. (confidence: low)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed, and it's a regression rather than a latent edge case, so fixed in 0cf06e7.

Verified the premise and the prior behavior:

iface == nil?            false                            // typed nil carries a type, so it passes the guard
xml.Unmarshal(typedNil): nil pointer passed to Unmarshal  // old behavior: clean error

So routing map targets through xmlMap turned that clean error into a panic. Reverting just the guard and running the new test reproduces it:

panic: runtime error: invalid memory address or nil pointer dereference

Guarded inside unmarshalXMLToMap rather than in WithAlwaysXMLResponse, so WithGenericResponse and any future caller are covered by the same check and it can't be reintroduced at a new call site. Returns InvalidArgument to match WithGenericResponse's existing nil handling. Test added: should error rather than panic on a typed-nil map target.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

General PR Review: cxp-846 fix XML list decoding in the generic XML decoder

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 5a7eaa0cca95.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness. This change reshapes xmlMap so repeated XML siblings group into a []any under their shared element name (replacing the old slice-of-single-key-maps shape that jsonpath could not walk), and lets WithAlwaysXMLResponse decode a map-pointer target through that decoder. The decode logic is correct — document order is preserved, unique siblings stay reachable alongside repeated ones, and the single-child asymmetry is documented — and the permutation-style test table (3+ duplicates, mixed siblings, nested depths, single-child, 204/empty-body, struct-path-unchanged) exercises the shape thoroughly. Triage: the failure mode is silent-empty (high silence) but the output is decode-time in-memory only, not durable serialized state, has no version-pair or scale dependence, and remediation is a connector redeploy — not a HIGH-risk contract change. This is a deliberate, well-documented default-shape change to a shared decoder; downstream consumers of the XML output should be aware, though the old shape was demonstrably unusable by config-driven callers. No blocking issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/uhttp/wrapper.go:239 — a typed-nil map-pointer target bypasses the response == nil guard and would nil-panic at the map assignment in unmarshalXMLToMap; consider mirroring the explicit nil check WithGenericResponse uses. (low confidence)
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In pkg/uhttp/wrapper.go:
- Around line 230-239: The new map-pointer branch in WithAlwaysXMLResponse
  type-asserts response to a map pointer and passes it to unmarshalXMLToMap,
  which ends by assigning through the pointer. A caller passing a typed-nil map
  pointer is not caught by the earlier response == nil check (an interface holding
  a nil typed pointer is not equal to nil), so with a non-empty body the code
  dereferences a nil pointer and panics. Add an explicit guard for a nil map
  pointer (return nil, or return an InvalidArgument status), mirroring the
  response == nil guard already present in WithGenericResponse. Low-likelihood
  call pattern but the branch is new, so guarding it costs nothing.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

agustin-conductor added a commit that referenced this pull request Aug 5, 2026
A typed-nil target such as (*map[string]any)(nil) gets past the
`response == nil` check in WithAlwaysXMLResponse, because the interface
still carries a type. The map branch was then reached with a nil pointer
and assigning through it panicked with a nil-pointer dereference.

encoding/xml rejected that input with "nil pointer passed to Unmarshal",
so routing map targets through xmlMap had turned a clean error into a
panic. Guard inside unmarshalXMLToMap rather than at each call site, so
neither this option nor WithGenericResponse nor any future caller can
assign through a nil pointer.

Reported by the PR review bot on #1061.

CXP-846

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

General PR Review: cxp-846 decode XML into a map target in WithAlwaysXMLResponse

Blocking Issues: 0 | Suggestions: 2 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base abc69f3badc5.
Review mode: full
View review run

Review Summary

Scanned the full PR diff (pkg/uhttp/wrapper.go, pkg/uhttp/xml.go, pkg/uhttp/wrapper_test.go) for security and correctness; no dependency, proto, or serialized-state surfaces are touched. The two prior findings that were fixable are addressed: the typed-nil *map[string]any target now returns InvalidArgument instead of panicking (wrapper.go:257), and the inaccurate non-map comment is replaced by handling that keys both the root-list and root-text cases (wrapper.go:265-285). The remaining prior note about unbounded recursion depth in unmarshalXMLElement is pre-existing and not re-flagged. The new WithAlwaysXMLResponse map branch is correctly scoped by target type, so typed-struct callers are untouched — but the last commit's root-keying also flows into the already-shipping WithGenericResponse, which is where both suggestions land.

Risk triage (per docs/BUG_CATCHING.md §2): Silence — yes, the root-keying turns a loud unsupported XML structure error into a silently arity-dependent top-level key. Durability — no, nothing is persisted; no c1z content, sync token, or wire type. Uncontrolled dimensions — yes, but only data-dependent (1 vs N children flips the key); no schedule or version-pair dependence. Consumer distance — downstream connectors reading via baton-http path expressions. Consequence — remediation rung 2 (fix a config / re-sync), not rung 4-5. Net: elevated escape, contained consequence. The right instrument here is an arity permutation table, and the PR does contain one — wrapper_test.go:303-347 pins the 1-item and N-item shapes against each other — but only on the WithAlwaysXMLResponse path, not on the pre-existing WithGenericResponse that inherited the same change. Extending that table is the ask; no escalation beyond this review is warranted.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/uhttp/wrapper.go:434WithGenericResponse is no longer a pure extraction: root-level lists and root-text bodies changed from Internal: unsupported XML structure to success, and the arity seam now applies to this shipping API with no case in TestWrapper_WithGenericResponse pinning it. The PR description still describes this branch as "same error wrapping" and still lists root-text as erroring. (medium confidence)
  • pkg/uhttp/wrapper.go:283WithGenericResponse's doc comment promises lists land in the items field; the XML branch now puts a root-level list under the root element's own name instead. (medium confidence)
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/uhttp/wrapper.go`:
- Around line 434: The XML branch of `WithGenericResponse` now delegates to the shared
  `unmarshalXMLToMap`, which keys non-map root content by the root element name. This
  changed `WithGenericResponse`'s observable behavior: `<users><user/><user/></users>`
  previously returned `Internal: unsupported XML structure: []map[string]interface {}`
  and now succeeds as `{"users": [...]}`, and `<Code>OK</Code>` previously errored and
  now returns `{"Code": "OK"}`. The direction is error-to-success so no working caller
  breaks, but the arity seam documented at lines 278-282 now applies to this
  already-shipping API too: 2+ children key on the root name while 1 child keys on the
  child name. Add cases to `TestWrapper_WithGenericResponse` in
  `pkg/uhttp/wrapper_test.go` that pin both shapes directly through
  `WithGenericResponse` (a root-level list with 2 items, the same document with 1 item,
  and a text-only root), rather than relying on the equivalent cases that only exist on
  the `WithAlwaysXMLResponse` path. Also update the PR description, which still claims
  the `WithGenericResponse` change is "a pure extraction ... same error wrapping" and
  still lists root-text-only as producing `Internal: unsupported XML structure: string`.

- Around line 389: The `WithGenericResponse` doc comment states "If the response is a
  list, its values will be put into the \"items\" field." The JSON branch still honors
  this, but the XML branch now places a root-level list under the root element's own
  name instead. Update the comment to describe the XML branch's actual behavior so the
  public contract matches both branches.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Comment thread pkg/uhttp/xml.go Outdated
// zero value of the assertion is a nil []any, which append handles,
// so the first occurrence creates the slice.
list, _ := result[e.key].([]any)
result[e.key] = append(list, e.value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this changes the structure of decoded XML, any connector that uses WithXMLResponse/WithGenericResponse will need to be updated, right? It looks like only a few connectors call WithXMLResponse directly: https://github.com/search?q=org%3AConductorOne+WithXMLResponse&type=code and only baton-http calls WithGenericResponse(), so that's acceptable.

Will an existing baton-http config break because of this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on what I've research with claude the baton connectors would not be affected "No updates needed for panorama, litmos, sage-intacct, or sap-grc. xmlMap is
unreachable from WithXMLResponse, all their targets are typed structs, and
they build and test identically against patched vs unpatched v0.22.0."

But baton-http is trickier and I'm not sure how to evaluate the impact, which would depend on how the config.yaml is set.
2 paths

mechanism: jsonpath
used by: items_path, item_path, entitlements_path, resources_path,
details/secondary EvaluateJSONPath
today: broken — error, or silently 0 items
after my change: fixed

mechanism: CEL / templates
used by: cel: and tmpl: expressions
today: works correctly
after my change: breaks — loud on indexing, silent N → 1 on size/len

the second one is a problem, silently losing pages.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we're safe to make this change. There are no active http connectors in prod that use this part of the config.

@agustin-conductor
agustin-conductor marked this pull request as draft August 6, 2026 18:14
@agustin-conductor agustin-conductor changed the title cxp-846 fix XML list decoding in the generic XML decoder cxp-846 decode XML into a map target in WithAlwaysXMLResponse Aug 6, 2026
@agustin-conductor
agustin-conductor marked this pull request as ready for review August 6, 2026 20:38
encoding/xml cannot unmarshal into a map, so WithAlwaysXMLResponse failed
for every response with a body when handed a *map[string]any, returning
"unknown type map[string]interface {}". Callers wanting an arbitrary XML
document as a map had no working option, which is why baton-http's
`parse_as: xml` has never functioned.

Route that one target type through the xmlMap decoder the generic path
already uses, and share the code as unmarshalXMLToMap. Any other target
still goes straight to xml.Unmarshal, so callers passing a typed struct
are untouched, and WithXMLResponse is not modified at all.

This changes no shapes: the map target now produces exactly what
WithGenericResponse already produces for the same document.

The behavior change is confined to a branch that previously always
failed:

  XML body, map target       error "unknown type map…"      -> decoded map
  204 / empty body           error                          -> nil, map empty
  typed-nil map target       error "nil pointer passed…"    -> InvalidArgument
  root holds only text       error                          -> Internal

Nothing that returns successfully today returns anything different. The
typed-nil guard lives inside unmarshalXMLToMap so assigning through the
pointer cannot panic; a typed nil survives an `any == nil` check because
the interface still carries a type.

Part of CXP-846.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread pkg/uhttp/wrapper.go
Comment on lines +265 to +270
vMap, ok := xm.data.(map[string]any)
if !ok {
// A document whose root holds only text decodes to a string, which has no
// sensible map representation.
return status.Errorf(codes.Internal, "unsupported XML structure: %T", xm.data)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the comment says the non-map case is "a document whose root holds only text", but unmarshalXMLElement also returns []map[string]any whenever the root's direct children repeat (see xml_test.go:24). So a very common list shape — <Users><User>…</User><User>…</User></Users> — still hard-fails here with Internal: unsupported XML structure: []map[string]interface {}, which is arguably the main case parse_as: xml needs. Pre-existing in WithGenericResponse and not a regression, but worth either handling the slice case (e.g. wrap it under the root element name) or at least correcting the comment and adding a test so the limitation is explicit. (medium confidence)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on both halves — the comment was wrong, and the slice case is the more important one. Fixed in 681b067.

The comment. Corrected to name both ways the root's content can be a non-map, since I'd only documented the string case.

The slice case. Handled as you suggested, keyed by the root element name — xmlMap now records start.Name.Local, which it was discarding:

<users><user><login>a</login></user><user><login>b</login></user></users>
  before: Internal: unsupported XML structure: []map[string]interface {}
  after:  {"users": [{"user":{"login":"a"}}, {"user":{"login":"b"}}]}

I did the text-only root the same way (<Code>OK</Code>{"Code": "OK"}), so unmarshalXMLToMap can no longer fail on structure at all. Both were errors before, here and on WithGenericResponse, so it stays error → success.

One caveat worth recording, since wrapping is reactive rather than a real fix. It inherits the decoder's arity asymmetry:

document decoded path
<users><user/><user/></users> {"users": [{"user":…},{"user":…}]} users
<users><user/></users> {"user": {…}} user

A single child means nothing repeats, so the content is a map, so the root name is discarded as usual — which means one config can't serve both arities for a root-level list. Measured, not assumed, and pinned by should keep stripping the root when its content is a map so it can't drift silently.

Still a clear win: the ≥2 case is the one every real tenant hits, and it went from an opaque SDK-internal error to something reachable by a path.

What actually closes the seam is grouping repeated children under their shared name — which would also make this slice branch unreachable. That was in an earlier revision of this PR and is deferred (see the PR description) because it changes what existing CEL and template expressions read on paths that never touch items extraction. Your finding is a second argument for it, so it's parked for its own audit rather than dropped.

Verified end-to-end against ConductorOne/baton-http#144 through a Go workspace: root-level lists now sync at both arities (with the path difference above), nested lists sync at both arities from a single path.

Comment thread pkg/uhttp/wrapper.go
if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(resp.Body) == 0 {
return nil
}
return unmarshalXMLToMap(genericResponse, resp)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this newly routes bodies that previously always errored into unmarshalXMLElement, which recurses once per nesting level with no depth cap, on a body read with an unbounded io.ReadAll (wrapper.go:503) and with the content-type check bypassed by design. A few MB of nested open tags from a hostile or broken endpoint is a fatal (unrecoverable) stack overflow rather than a returned error. Pre-existing in the WithGenericResponse path, so not introduced here, but a depth limit in unmarshalXMLElement would cheaply close it. (low confidence on real-world reachability)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

unmarshalXMLToMap required the root element's content to be a map and
returned Internal "unsupported XML structure" otherwise. That is
reachable two ways, and the review flagged that the comment named only
the second:

  - The root's own children repeat: <Users><User/><User/></Users> decodes
    to a []map[string]any. A root-level list is a common API shape, so
    this hard-failed for a whole class of responses -- arguably the main
    case parse_as: xml is wanted for.
  - The root holds only text: <Code>OK</Code> decodes to a string.

Key both by the root element name, which the decoder recorded nowhere and
otherwise discards, so the document is reachable by a path instead of
being an error. unmarshalXMLToMap can no longer fail on structure at all.

Both cases were errors before, on this path and on WithGenericResponse,
so this stays error -> success with no working caller affected. xml.go
gains a root field but no decoding change, so shapes are unchanged.

Leaves an arity seam, now pinned by a test: a root holding a single
<User> decodes to a map and keeps the root stripped, so its path is
"User" while the repeated case is "Users", and one config cannot serve
both. Grouping repeated children under their shared name is what closes
that, and would make the slice case here unreachable.

Part of CXP-846.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread pkg/uhttp/wrapper.go
return status.Errorf(codes.Internal, "unsupported XML structure: %T", xm.data)
}
return nil
return unmarshalXMLToMap(response, resp)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: This is no longer a pure extraction — the shared helper's new root-keying changes WithGenericResponse's observable behavior. <users><user/><user/></users> used to return Internal: unsupported XML structure: []map[string]interface {} and now succeeds as {"users": [...]}; <Code>OK</Code> used to error and now returns {"Code": "OK"}. That direction is error→success so it can't break a working caller, but it means the arity seam documented at lines 278-282 now also applies to this already-shipping API: the same endpoint keys on the root name at 2+ items and on the child name at 1 item, and the 2-item case used to be a loud error rather than a silently different key. The new tests all go through WithAlwaysXMLResponse; TestWrapper_WithGenericResponse has no case pinning either new shape. Worth adding the 1-item/N-item pair there directly, and correcting the PR description, which still says this branch is "same decoder, same error wrapping" and still lists root-text as producing Internal: unsupported XML structure: string. (medium confidence)

Comment thread pkg/uhttp/wrapper.go
// repeated case is "Users". One config cannot serve both. Closing that
// needs the decoder to group repeated children under their shared name,
// which would also make the slice case here unreachable.
*response = map[string]any{xm.root: xm.data}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Because WithGenericResponse now shares this helper, this line also changes that function's documented contract. Its doc comment (line 389) says "if the response is a list, its values will be put into the items field" — the JSON branch still honors that, but an XML root-level list now lands under the root element's own name instead. Worth updating that comment so the public contract matches both branches. (medium confidence)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@agustin-conductor agustin-conductor changed the title cxp-846 decode XML into a map target in WithAlwaysXMLResponse cxp-846 return XML as a generic map: map targets and non-map roots Aug 11, 2026
@btipling btipling removed their assignment Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants